4acbc552773847ace256536d0aa949f44bce276b
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / dm / mw.rcfilters.dm.FiltersViewModel.js
1 ( function ( mw, $ ) {
2 /**
3 * View model for the filters selection and display
4 *
5 * @mixins OO.EventEmitter
6 * @mixins OO.EmitterList
7 *
8 * @constructor
9 */
10 mw.rcfilters.dm.FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
11 // Mixin constructor
12 OO.EventEmitter.call( this );
13 OO.EmitterList.call( this );
14
15 this.groups = {};
16 this.defaultParams = {};
17 this.highlightEnabled = false;
18 this.parameterMap = {};
19 this.emptyParameterState = null;
20
21 this.views = {};
22 this.currentView = 'default';
23
24 // Events
25 this.aggregate( { update: 'filterItemUpdate' } );
26 this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
27 };
28
29 /* Initialization */
30 OO.initClass( mw.rcfilters.dm.FiltersViewModel );
31 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
32 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
33
34 /* Events */
35
36 /**
37 * @event initialize
38 *
39 * Filter list is initialized
40 */
41
42 /**
43 * @event update
44 *
45 * Model has been updated
46 */
47
48 /**
49 * @event itemUpdate
50 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
51 *
52 * Filter item has changed
53 */
54
55 /**
56 * @event highlightChange
57 * @param {boolean} Highlight feature is enabled
58 *
59 * Highlight feature has been toggled enabled or disabled
60 */
61
62 /* Methods */
63
64 /**
65 * Re-assess the states of filter items based on the interactions between them
66 *
67 * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
68 * method will go over the state of all items
69 */
70 mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
71 var allSelected,
72 model = this,
73 iterationItems = item !== undefined ? [ item ] : this.getItems();
74
75 iterationItems.forEach( function ( checkedItem ) {
76 var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
77 groupModel = checkedItem.getGroupModel();
78
79 // Check for subsets (included filters) plus the item itself:
80 allCheckedItems.forEach( function ( filterItemName ) {
81 var itemInSubset = model.getItemByName( filterItemName );
82
83 itemInSubset.toggleIncluded(
84 // If any of itemInSubset's supersets are selected, this item
85 // is included
86 itemInSubset.getSuperset().some( function ( supersetName ) {
87 return ( model.getItemByName( supersetName ).isSelected() );
88 } )
89 );
90 } );
91
92 // Update coverage for the changed group
93 if ( groupModel.isFullCoverage() ) {
94 allSelected = groupModel.areAllSelected();
95 groupModel.getItems().forEach( function ( filterItem ) {
96 filterItem.toggleFullyCovered( allSelected );
97 } );
98 }
99 } );
100
101 // Check for conflicts
102 // In this case, we must go over all items, since
103 // conflicts are bidirectional and depend not only on
104 // individual items, but also on the selected states of
105 // the groups they're in.
106 this.getItems().forEach( function ( filterItem ) {
107 var inConflict = false,
108 filterItemGroup = filterItem.getGroupModel();
109
110 // For each item, see if that item is still conflicting
111 $.each( model.groups, function ( groupName, groupModel ) {
112 if ( filterItem.getGroupName() === groupName ) {
113 // Check inside the group
114 inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
115 } else {
116 // According to the spec, if two items conflict from two different
117 // groups, the conflict only lasts if the groups **only have selected
118 // items that are conflicting**. If a group has selected items that
119 // are conflicting and non-conflicting, the scope of the result has
120 // expanded enough to completely remove the conflict.
121
122 // For example, see two groups with conflicts:
123 // userExpLevel: [
124 // {
125 // name: 'experienced',
126 // conflicts: [ 'unregistered' ]
127 // }
128 // ],
129 // registration: [
130 // {
131 // name: 'registered',
132 // },
133 // {
134 // name: 'unregistered',
135 // }
136 // ]
137 // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
138 // because, inherently, 'experienced' filter only includes registered users, and so
139 // both filters are in conflict with one another.
140 // However, the minute we select 'registered', the scope of our results
141 // has expanded to no longer have a conflict with 'experienced' filter, and
142 // so the conflict is removed.
143
144 // In our case, we need to check if the entire group conflicts with
145 // the entire item's group, so we follow the above spec
146 inConflict = (
147 // The foreign group is in conflict with this item
148 groupModel.areAllSelectedInConflictWith( filterItem ) &&
149 // Every selected member of the item's own group is also
150 // in conflict with the other group
151 filterItemGroup.getSelectedItems().every( function ( otherGroupItem ) {
152 return groupModel.areAllSelectedInConflictWith( otherGroupItem );
153 } )
154 );
155 }
156
157 // If we're in conflict, this will return 'false' which
158 // will break the loop. Otherwise, we're not in conflict
159 // and the loop continues
160 return !inConflict;
161 } );
162
163 // Toggle the item state
164 filterItem.toggleConflicted( inConflict );
165 } );
166 };
167
168 /**
169 * Get whether the model has any conflict in its items
170 *
171 * @return {boolean} There is a conflict
172 */
173 mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () {
174 return this.getItems().some( function ( filterItem ) {
175 return filterItem.isSelected() && filterItem.isConflicted();
176 } );
177 };
178
179 /**
180 * Get the first item with a current conflict
181 *
182 * @return {mw.rcfilters.dm.FilterItem} Conflicted item
183 */
184 mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () {
185 var conflictedItem;
186
187 $.each( this.getItems(), function ( index, filterItem ) {
188 if ( filterItem.isSelected() && filterItem.isConflicted() ) {
189 conflictedItem = filterItem;
190 return false;
191 }
192 } );
193
194 return conflictedItem;
195 };
196
197 /**
198 * Set filters and preserve a group relationship based on
199 * the definition given by an object
200 *
201 * @param {Array} filterGroups Filters definition
202 * @param {Object} [views] Extra views definition
203 * Expected in the following format:
204 * {
205 * namespaces: {
206 * label: 'namespaces', // Message key
207 * trigger: ':',
208 * groups: [
209 * {
210 * // Group info
211 * name: 'namespaces' // Parameter name
212 * title: 'namespaces' // Message key
213 * type: 'string_options',
214 * separator: ';',
215 * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
216 * fullCoverage: true
217 * items: []
218 * }
219 * ]
220 * }
221 * }
222 */
223 mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
224 var filterConflictResult, groupConflictResult,
225 allViews = {},
226 model = this,
227 items = [],
228 groupConflictMap = {},
229 filterConflictMap = {},
230 /*!
231 * Expand a conflict definition from group name to
232 * the list of all included filters in that group.
233 * We do this so that the direct relationship in the
234 * models are consistently item->items rather than
235 * mixing item->group with item->item.
236 *
237 * @param {Object} obj Conflict definition
238 * @return {Object} Expanded conflict definition
239 */
240 expandConflictDefinitions = function ( obj ) {
241 var result = {};
242
243 $.each( obj, function ( key, conflicts ) {
244 var filterName,
245 adjustedConflicts = {};
246
247 conflicts.forEach( function ( conflict ) {
248 var filter;
249
250 if ( conflict.filter ) {
251 filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
252 filter = model.getItemByName( filterName );
253
254 // Rename
255 adjustedConflicts[ filterName ] = $.extend(
256 {},
257 conflict,
258 {
259 filter: filterName,
260 item: filter
261 }
262 );
263 } else {
264 // This conflict is for an entire group. Split it up to
265 // represent each filter
266
267 // Get the relevant group items
268 model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
269 // Rebuild the conflict
270 adjustedConflicts[ groupItem.getName() ] = $.extend(
271 {},
272 conflict,
273 {
274 filter: groupItem.getName(),
275 item: groupItem
276 }
277 );
278 } );
279 }
280 } );
281
282 result[ key ] = adjustedConflicts;
283 } );
284
285 return result;
286 };
287
288 // Reset
289 this.clearItems();
290 this.groups = {};
291 this.views = {};
292
293 // Clone
294 filterGroups = OO.copy( filterGroups );
295
296 // Normalize definition from the server
297 filterGroups.forEach( function ( data ) {
298 var i;
299 // What's this information needs to be normalized
300 data.whatsThis = {
301 body: data.whatsThisBody,
302 header: data.whatsThisHeader,
303 linkText: data.whatsThisLinkText,
304 url: data.whatsThisUrl
305 };
306
307 // Title is a msg-key
308 data.title = data.title ? mw.msg( data.title ) : data.name;
309
310 // Filters are given to us with msg-keys, we need
311 // to translate those before we hand them off
312 for ( i = 0; i < data.filters.length; i++ ) {
313 data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
314 data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
315 }
316 } );
317
318 // Collect views
319 allViews = $.extend( true, {
320 'default': {
321 title: mw.msg( 'rcfilters-filterlist-title' ),
322 groups: filterGroups
323 }
324 }, views );
325
326 // Go over all views
327 $.each( allViews, function ( viewName, viewData ) {
328 // Define the view
329 model.views[ viewName ] = {
330 name: viewData.name,
331 title: viewData.title,
332 trigger: viewData.trigger
333 };
334
335 // Go over groups
336 viewData.groups.forEach( function ( groupData ) {
337 var group = groupData.name;
338
339 if ( !model.groups[ group ] ) {
340 model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
341 group,
342 $.extend( true, {}, groupData, { view: viewName } )
343 );
344 }
345
346 model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
347 items = items.concat( model.groups[ group ].getItems() );
348
349 // Prepare conflicts
350 if ( groupData.conflicts ) {
351 // Group conflicts
352 groupConflictMap[ group ] = groupData.conflicts;
353 }
354
355 groupData.filters.forEach( function ( itemData ) {
356 var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
357 // Filter conflicts
358 if ( itemData.conflicts ) {
359 filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
360 }
361 } );
362 } );
363 } );
364
365 // Add item references to the model, for lookup
366 this.addItems( items );
367
368 // Expand conflicts
369 groupConflictResult = expandConflictDefinitions( groupConflictMap );
370 filterConflictResult = expandConflictDefinitions( filterConflictMap );
371
372 // Set conflicts for groups
373 $.each( groupConflictResult, function ( group, conflicts ) {
374 model.groups[ group ].setConflicts( conflicts );
375 } );
376
377 // Set conflicts for items
378 $.each( filterConflictResult, function ( filterName, conflicts ) {
379 var filterItem = model.getItemByName( filterName );
380 // set conflicts for items in the group
381 filterItem.setConflicts( conflicts );
382 } );
383
384 // Create a map between known parameters and their models
385 $.each( this.groups, function ( group, groupModel ) {
386 if (
387 groupModel.getType() === 'send_unselected_if_any' ||
388 groupModel.getType() === 'boolean'
389 ) {
390 // Individual filters
391 groupModel.getItems().forEach( function ( filterItem ) {
392 model.parameterMap[ filterItem.getParamName() ] = filterItem;
393 } );
394 } else if (
395 groupModel.getType() === 'string_options' ||
396 groupModel.getType() === 'single_option'
397 ) {
398 // Group
399 model.parameterMap[ groupModel.getName() ] = groupModel;
400 }
401 } );
402
403 this.currentView = 'default';
404
405 this.updateHighlightedState();
406
407 // Finish initialization
408 this.emit( 'initialize' );
409 };
410
411 /**
412 * Update filter view model state based on a parameter object
413 *
414 * @param {Object} params Parameters object
415 */
416 mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
417 // For arbitrary numeric single_option values make sure the values
418 // are normalized to fit within the limits
419 $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
420 params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
421 } );
422
423 // Update filter states
424 this.toggleFiltersSelected(
425 this.getFiltersFromParameters(
426 params
427 )
428 );
429
430 // Update highlight state
431 this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
432 var color = params[ filterItem.getName() + '_color' ];
433 if ( color ) {
434 filterItem.setHighlightColor( color );
435 } else {
436 filterItem.clearHighlightColor();
437 }
438 } );
439 this.updateHighlightedState();
440
441 // Check all filter interactions
442 this.reassessFilterInteractions();
443 };
444
445 /**
446 * Get a representation of an empty (falsey) parameter state
447 *
448 * @return {Object} Empty parameter state
449 */
450 mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyParameterState = function () {
451 if ( !this.emptyParameterState ) {
452 this.emptyParameterState = $.extend(
453 true,
454 {},
455 this.getParametersFromFilters( {} ),
456 this.getEmptyHighlightParameters()
457 );
458 }
459 return this.emptyParameterState;
460 };
461
462 /**
463 * Get a representation of only the non-falsey parameters
464 *
465 * @param {Object} [parameters] A given parameter state to minimize. If not given the current
466 * state of the system will be used.
467 * @return {Object} Empty parameter state
468 */
469 mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
470 var result = {};
471
472 parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
473
474 // Params
475 $.each( this.getEmptyParameterState(), function ( param, value ) {
476 if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
477 result[ param ] = parameters[ param ];
478 }
479 } );
480
481 // Highlights
482 Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
483 if ( parameters[ param ] ) {
484 // If a highlight parameter is not undefined and not null
485 // add it to the result
486 result[ param ] = parameters[ param ];
487 }
488 } );
489
490 return result;
491 };
492
493 /**
494 * Get a representation of the full parameter list, including all base values
495 *
496 * @return {Object} Full parameter representation
497 */
498 mw.rcfilters.dm.FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
499 return $.extend(
500 true,
501 {},
502 this.getEmptyParameterState(),
503 this.getCurrentParameterState()
504 );
505 };
506
507 /**
508 * Get a parameter representation of the current state of the model
509 *
510 * @param {boolean} [removeStickyParams] Remove sticky filters from final result
511 * @return {Object} Parameter representation of the current state of the model
512 */
513 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
514 var state = this.getMinimizedParamRepresentation( $.extend(
515 true,
516 {},
517 this.getParametersFromFilters( this.getSelectedState() ),
518 this.getHighlightParameters()
519 ) );
520
521 if ( removeStickyParams ) {
522 state = this.removeStickyParams( state );
523 }
524
525 return state;
526 };
527
528 /**
529 * Delete sticky parameters from given object.
530 *
531 * @param {Object} paramState Parameter state
532 * @return {Object} Parameter state without sticky parameters
533 */
534 mw.rcfilters.dm.FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
535 this.getStickyParams().forEach( function ( paramName ) {
536 delete paramState[ paramName ];
537 } );
538
539 return paramState;
540 };
541
542 /**
543 * Turn the highlight feature on or off
544 */
545 mw.rcfilters.dm.FiltersViewModel.prototype.updateHighlightedState = function () {
546 this.toggleHighlight( this.getHighlightedItems().length > 0 );
547 };
548
549 /**
550 * Get the object that defines groups by their name.
551 *
552 * @return {Object} Filter groups
553 */
554 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
555 return this.groups;
556 };
557
558 /**
559 * Get the object that defines groups that match a certain view by their name.
560 *
561 * @param {string} [view] Requested view. If not given, uses current view
562 * @return {Object} Filter groups matching a display group
563 */
564 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
565 var result = {};
566
567 view = view || this.getCurrentView();
568
569 $.each( this.groups, function ( groupName, groupModel ) {
570 if ( groupModel.getView() === view ) {
571 result[ groupName ] = groupModel;
572 }
573 } );
574
575 return result;
576 };
577
578 /**
579 * Get an array of filters matching the given display group.
580 *
581 * @param {string} [view] Requested view. If not given, uses current view
582 * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
583 */
584 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
585 var groups,
586 result = [];
587
588 view = view || this.getCurrentView();
589
590 groups = this.getFilterGroupsByView( view );
591
592 $.each( groups, function ( groupName, groupModel ) {
593 result = result.concat( groupModel.getItems() );
594 } );
595
596 return result;
597 };
598
599 /**
600 * Get the trigger for the requested view.
601 *
602 * @param {string} view View name
603 * @return {string} View trigger, if exists
604 */
605 mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
606 return ( this.views[ view ] && this.views[ view ].trigger ) || '';
607 };
608
609 /**
610 * Get the value of a specific parameter
611 *
612 * @param {string} name Parameter name
613 * @return {number|string} Parameter value
614 */
615 mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
616 return this.parameters[ name ];
617 };
618
619 /**
620 * Get the current selected state of the filters
621 *
622 * @param {boolean} onlySelected return an object containing only the selected filters
623 * @return {Object} Filters selected state
624 */
625 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
626 var i,
627 items = this.getItems(),
628 result = {};
629
630 for ( i = 0; i < items.length; i++ ) {
631 if ( !onlySelected || items[ i ].isSelected() ) {
632 result[ items[ i ].getName() ] = items[ i ].isSelected();
633 }
634 }
635
636 return result;
637 };
638
639 /**
640 * Get the current full state of the filters
641 *
642 * @return {Object} Filters full state
643 */
644 mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
645 var i,
646 items = this.getItems(),
647 result = {};
648
649 for ( i = 0; i < items.length; i++ ) {
650 result[ items[ i ].getName() ] = {
651 selected: items[ i ].isSelected(),
652 conflicted: items[ i ].isConflicted(),
653 included: items[ i ].isIncluded()
654 };
655 }
656
657 return result;
658 };
659
660 /**
661 * Get an object representing default parameters state
662 *
663 * @return {Object} Default parameter values
664 */
665 mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
666 var result = {};
667
668 // Get default filter state
669 $.each( this.groups, function ( name, model ) {
670 if ( !model.isSticky() ) {
671 $.extend( true, result, model.getDefaultParams() );
672 }
673 } );
674
675 return result;
676 };
677
678 /**
679 * Get a parameter representation of all sticky parameters
680 *
681 * @return {Object} Sticky parameter values
682 */
683 mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () {
684 var result = [];
685
686 $.each( this.groups, function ( name, model ) {
687 if ( model.isSticky() ) {
688 if ( model.isPerGroupRequestParameter() ) {
689 result.push( name );
690 } else {
691 // Each filter is its own param
692 result = result.concat( model.getItems().map( function ( filterItem ) {
693 return filterItem.getParamName();
694 } ) );
695 }
696 }
697 } );
698
699 return result;
700 };
701
702 /**
703 * Get a parameter representation of all sticky parameters
704 *
705 * @return {Object} Sticky parameter values
706 */
707 mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () {
708 var result = {};
709
710 $.each( this.groups, function ( name, model ) {
711 if ( model.isSticky() ) {
712 $.extend( true, result, model.getDefaultParams() );
713 }
714 } );
715
716 return result;
717 };
718
719 /**
720 * Analyze the groups and their filters and output an object representing
721 * the state of the parameters they represent.
722 *
723 * @param {Object} [filterDefinition] An object defining the filter values,
724 * keyed by filter names.
725 * @return {Object} Parameter state object
726 */
727 mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
728 var groupItemDefinition,
729 result = {},
730 groupItems = this.getFilterGroups();
731
732 if ( filterDefinition ) {
733 groupItemDefinition = {};
734 // Filter definition is "flat", but in effect
735 // each group needs to tell us its result based
736 // on the values in it. We need to split this list
737 // back into groupings so we can "feed" it to the
738 // loop below, and we need to expand it so it includes
739 // all filters (set to false)
740 this.getItems().forEach( function ( filterItem ) {
741 groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
742 groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = !!filterDefinition[ filterItem.getName() ];
743 } );
744 }
745
746 $.each( groupItems, function ( group, model ) {
747 $.extend(
748 result,
749 model.getParamRepresentation(
750 groupItemDefinition ?
751 groupItemDefinition[ group ] : null
752 )
753 );
754 } );
755
756 return result;
757 };
758
759 /**
760 * This is the opposite of the #getParametersFromFilters method; this goes over
761 * the given parameters and translates into a selected/unselected value in the filters.
762 *
763 * @param {Object} params Parameters query object
764 * @return {Object} Filter state object
765 */
766 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
767 var groupMap = {},
768 model = this,
769 result = {};
770
771 // Go over the given parameters, break apart to groupings
772 // The resulting object represents the group with its parameter
773 // values. For example:
774 // {
775 // group1: {
776 // param1: "1",
777 // param2: "0",
778 // param3: "1"
779 // },
780 // group2: "param4|param5"
781 // }
782 $.each( params, function ( paramName, paramValue ) {
783 var groupName,
784 itemOrGroup = model.parameterMap[ paramName ];
785
786 if ( itemOrGroup ) {
787 groupName = itemOrGroup instanceof mw.rcfilters.dm.FilterItem ?
788 itemOrGroup.getGroupName() : itemOrGroup.getName();
789
790 groupMap[ groupName ] = groupMap[ groupName ] || {};
791 groupMap[ groupName ][ paramName ] = paramValue;
792 }
793 } );
794
795 // Go over all groups, so we make sure we get the complete output
796 // even if the parameters don't include a certain group
797 $.each( this.groups, function ( groupName, groupModel ) {
798 result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
799 } );
800
801 return result;
802 };
803
804 /**
805 * Get the highlight parameters based on current filter configuration
806 *
807 * @return {Object} Object where keys are `<filter name>_color` and values
808 * are the selected highlight colors.
809 */
810 mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
811 var highlightEnabled = this.isHighlightEnabled(),
812 result = {};
813
814 this.getItems().forEach( function ( filterItem ) {
815 if ( filterItem.isHighlightSupported() ) {
816 result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
817 filterItem.getHighlightColor() :
818 null;
819 }
820 } );
821
822 return result;
823 };
824
825 /**
826 * Get an object representing the complete empty state of highlights
827 *
828 * @return {Object} Object containing all the highlight parameters set to their negative value
829 */
830 mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
831 var result = {};
832
833 this.getItems().forEach( function ( filterItem ) {
834 if ( filterItem.isHighlightSupported() ) {
835 result[ filterItem.getName() + '_color' ] = null;
836 }
837 } );
838
839 return result;
840 };
841
842 /**
843 * Get an array of currently applied highlight colors
844 *
845 * @return {string[]} Currently applied highlight colors
846 */
847 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
848 var result = [];
849
850 if ( this.isHighlightEnabled() ) {
851 this.getHighlightedItems().forEach( function ( filterItem ) {
852 var color = filterItem.getHighlightColor();
853
854 if ( result.indexOf( color ) === -1 ) {
855 result.push( color );
856 }
857 } );
858 }
859
860 return result;
861 };
862
863 /**
864 * Sanitize value group of a string_option groups type
865 * Remove duplicates and make sure to only use valid
866 * values.
867 *
868 * @private
869 * @param {string} groupName Group name
870 * @param {string[]} valueArray Array of values
871 * @return {string[]} Array of valid values
872 */
873 mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
874 var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
875 return filterItem.getParamName();
876 } );
877
878 return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
879 };
880
881 /**
882 * Check whether no visible filter is selected.
883 *
884 * Filter groups that are hidden or sticky are not shown in the
885 * active filters area and therefore not included in this check.
886 *
887 * @return {boolean} No visible filter is selected
888 */
889 mw.rcfilters.dm.FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
890 // Check if there are either any selected items or any items
891 // that have highlight enabled
892 return !this.getItems().some( function ( filterItem ) {
893 var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
894 active = ( filterItem.isSelected() || filterItem.isHighlighted() );
895 return visible && active;
896 } );
897 };
898
899 /**
900 * Check whether the invert state is a valid one. A valid invert state is one where
901 * there are actual namespaces selected.
902 *
903 * This is done to compare states to previous ones that may have had the invert model
904 * selected but effectively had no namespaces, so are not effectively different than
905 * ones where invert is not selected.
906 *
907 * @return {boolean} Invert is effectively selected
908 */
909 mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
910 return this.getInvertModel().isSelected() &&
911 this.getSelectedItems().some( function ( itemModel ) {
912 return itemModel.getGroupModel().getView() === 'namespace';
913 } );
914 };
915
916 /**
917 * Get the item that matches the given name
918 *
919 * @param {string} name Filter name
920 * @return {mw.rcfilters.dm.FilterItem} Filter item
921 */
922 mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
923 return this.getItems().filter( function ( item ) {
924 return name === item.getName();
925 } )[ 0 ];
926 };
927
928 /**
929 * Set all filters to false or empty/all
930 * This is equivalent to display all.
931 */
932 mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
933 this.getItems().forEach( function ( filterItem ) {
934 if ( !filterItem.getGroupModel().isSticky() ) {
935 this.toggleFilterSelected( filterItem.getName(), false );
936 }
937 }.bind( this ) );
938 };
939
940 /**
941 * Toggle selected state of one item
942 *
943 * @param {string} name Name of the filter item
944 * @param {boolean} [isSelected] Filter selected state
945 */
946 mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
947 var item = this.getItemByName( name );
948
949 if ( item ) {
950 item.toggleSelected( isSelected );
951 }
952 };
953
954 /**
955 * Toggle selected state of items by their names
956 *
957 * @param {Object} filterDef Filter definitions
958 */
959 mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
960 Object.keys( filterDef ).forEach( function ( name ) {
961 this.toggleFilterSelected( name, filterDef[ name ] );
962 }.bind( this ) );
963 };
964
965 /**
966 * Get a group model from its name
967 *
968 * @param {string} groupName Group name
969 * @return {mw.rcfilters.dm.FilterGroup} Group model
970 */
971 mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
972 return this.groups[ groupName ];
973 };
974
975 /**
976 * Get all filters within a specified group by its name
977 *
978 * @param {string} groupName Group name
979 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
980 */
981 mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
982 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
983 };
984
985 /**
986 * Find items whose labels match the given string
987 *
988 * @param {string} query Search string
989 * @param {boolean} [returnFlat] Return a flat array. If false, the result
990 * is an object whose keys are the group names and values are an array of
991 * filters per group. If set to true, returns an array of filters regardless
992 * of their groups.
993 * @return {Object} An object of items to show
994 * arranged by their group names
995 */
996 mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
997 var i, searchIsEmpty,
998 groupTitle,
999 result = {},
1000 flatResult = [],
1001 view = this.getViewByTrigger( query.substr( 0, 1 ) ),
1002 items = this.getFiltersByView( view );
1003
1004 // Normalize so we can search strings regardless of case and view
1005 query = query.trim().toLowerCase();
1006 if ( view !== 'default' ) {
1007 query = query.substr( 1 );
1008 }
1009 // Trim again to also intercept cases where the spaces were after the trigger
1010 // eg: '# str'
1011 query = query.trim();
1012
1013 // Check if the search if actually empty; this can be a problem when
1014 // we use prefixes to denote different views
1015 searchIsEmpty = query.length === 0;
1016
1017 // item label starting with the query string
1018 for ( i = 0; i < items.length; i++ ) {
1019 if (
1020 searchIsEmpty ||
1021 items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
1022 (
1023 // For tags, we want the parameter name to be included in the search
1024 view === 'tags' &&
1025 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1026 )
1027 ) {
1028 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1029 result[ items[ i ].getGroupName() ].push( items[ i ] );
1030 flatResult.push( items[ i ] );
1031 }
1032 }
1033
1034 if ( $.isEmptyObject( result ) ) {
1035 // item containing the query string in their label, description, or group title
1036 for ( i = 0; i < items.length; i++ ) {
1037 groupTitle = items[ i ].getGroupModel().getTitle();
1038 if (
1039 searchIsEmpty ||
1040 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
1041 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
1042 groupTitle.toLowerCase().indexOf( query ) > -1 ||
1043 (
1044 // For tags, we want the parameter name to be included in the search
1045 view === 'tags' &&
1046 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1047 )
1048 ) {
1049 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1050 result[ items[ i ].getGroupName() ].push( items[ i ] );
1051 flatResult.push( items[ i ] );
1052 }
1053 }
1054 }
1055
1056 return returnFlat ? flatResult : result;
1057 };
1058
1059 /**
1060 * Get items that are highlighted
1061 *
1062 * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
1063 */
1064 mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () {
1065 return this.getItems().filter( function ( filterItem ) {
1066 return filterItem.isHighlightSupported() &&
1067 filterItem.getHighlightColor();
1068 } );
1069 };
1070
1071 /**
1072 * Get items that allow highlights even if they're not currently highlighted
1073 *
1074 * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
1075 */
1076 mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
1077 return this.getItems().filter( function ( filterItem ) {
1078 return filterItem.isHighlightSupported();
1079 } );
1080 };
1081
1082 /**
1083 * Get all selected items
1084 *
1085 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
1086 */
1087 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedItems = function () {
1088 var allSelected = [];
1089
1090 $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
1091 allSelected = allSelected.concat( groupModel.getSelectedItems() );
1092 } );
1093
1094 return allSelected;
1095 };
1096
1097 /**
1098 * Switch the current view
1099 *
1100 * @param {string} view View name
1101 * @fires update
1102 */
1103 mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
1104 if ( this.views[ view ] && this.currentView !== view ) {
1105 this.currentView = view;
1106 this.emit( 'update' );
1107 }
1108 };
1109
1110 /**
1111 * Get the current view
1112 *
1113 * @return {string} Current view
1114 */
1115 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
1116 return this.currentView;
1117 };
1118
1119 /**
1120 * Get the label for the current view
1121 *
1122 * @param {string} viewName View name
1123 * @return {string} Label for the current view
1124 */
1125 mw.rcfilters.dm.FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
1126 viewName = viewName || this.getCurrentView();
1127
1128 return this.views[ viewName ] && this.views[ viewName ].title;
1129 };
1130
1131 /**
1132 * Get the view that fits the given trigger
1133 *
1134 * @param {string} trigger Trigger
1135 * @return {string} Name of view
1136 */
1137 mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
1138 var result = 'default';
1139
1140 $.each( this.views, function ( name, data ) {
1141 if ( data.trigger === trigger ) {
1142 result = name;
1143 }
1144 } );
1145
1146 return result;
1147 };
1148
1149 /**
1150 * Toggle the highlight feature on and off.
1151 * Propagate the change to filter items.
1152 *
1153 * @param {boolean} enable Highlight should be enabled
1154 * @fires highlightChange
1155 */
1156 mw.rcfilters.dm.FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
1157 enable = enable === undefined ? !this.highlightEnabled : enable;
1158
1159 if ( this.highlightEnabled !== enable ) {
1160 this.highlightEnabled = enable;
1161 this.emit( 'highlightChange', this.highlightEnabled );
1162 }
1163 };
1164
1165 /**
1166 * Check if the highlight feature is enabled
1167 * @return {boolean}
1168 */
1169 mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () {
1170 return !!this.highlightEnabled;
1171 };
1172
1173 /**
1174 * Toggle the inverted namespaces property on and off.
1175 * Propagate the change to namespace filter items.
1176 *
1177 * @param {boolean} enable Inverted property is enabled
1178 */
1179 mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
1180 this.toggleFilterSelected( this.getInvertModel().getName(), enable );
1181 };
1182
1183 /**
1184 * Get the model object that represents the 'invert' filter
1185 *
1186 * @return {mw.rcfilters.dm.FilterItem}
1187 */
1188 mw.rcfilters.dm.FiltersViewModel.prototype.getInvertModel = function () {
1189 return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
1190 };
1191
1192 /**
1193 * Set highlight color for a specific filter item
1194 *
1195 * @param {string} filterName Name of the filter item
1196 * @param {string} color Selected color
1197 */
1198 mw.rcfilters.dm.FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
1199 this.getItemByName( filterName ).setHighlightColor( color );
1200 };
1201
1202 /**
1203 * Clear highlight for a specific filter item
1204 *
1205 * @param {string} filterName Name of the filter item
1206 */
1207 mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
1208 this.getItemByName( filterName ).clearHighlightColor();
1209 };
1210
1211 /**
1212 * Return a version of the given string that is without any
1213 * view triggers.
1214 *
1215 * @param {string} str Given string
1216 * @return {string} Result
1217 */
1218 mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
1219 if ( this.getViewByTrigger( str.substr( 0, 1 ) ) !== 'default' ) {
1220 str = str.substr( 1 );
1221 }
1222
1223 return str;
1224 };
1225 }( mediaWiki, jQuery ) );